/*
 * Copyright 2002 (C) TJDO.
 * All rights reserved.
 *
 * This software is distributed under the terms of the TJDO License version 1.0.
 * See the terms of the TJDO License in the documentation provided with this software.
 *
 * $Id: BaseTable.java,v 1.8 2003/04/28 00:52:17 jackknifebarber Exp $
 */

package com.triactive.jdo.store;

import com.triactive.jdo.model.ClassMetaData;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.log4j.Category;


abstract class BaseTable extends AbstractTable
{
    private static final Category LOG = Category.getInstance(BaseTable.class);

    public BaseTable(StoreManager storeMgr)
    {
        super(storeMgr);
    }


    public BaseTable(SQLIdentifier name, StoreManager storeMgr)
    {
        super(name, storeMgr);
    }


    public PrimaryKey getExpectedPrimaryKey()
    {
        PrimaryKey pk = null;

        Iterator i = columns.iterator();

        while (i.hasNext())
        {
            Column col = (Column)i.next();

            if (col.isPrimaryKeyPart())
            {
                if (pk == null)
                    pk = new PrimaryKey(this);

                pk.addColumn(col);
            }
        }

        return pk;
    }


    public void create(Connection conn) throws SQLException
    {
        LOG.info("Creating table: " + this);

        super.create(conn);
    }


    public boolean validate(int flags, Connection conn) throws SQLException
    {
        assertIsInitialized();

        boolean dbWasModified = false;

        if ((flags & VALIDATE) != 0)
        {
            int tableType = storeMgr.getTableType(name, conn);

            if (tableType == TABLE_TYPE_MISSING)
            {
                if ((flags & AUTO_CREATE) == 0)
                    throw new MissingTableException(this);

                create(conn);
                dbWasModified = true;
            }
            else
            {
                LOG.info("Validating table: " + this);

                if (tableType != TABLE_TYPE_BASE_TABLE)
                    throw new NotABaseTableException(this);

                HashMap unvalidated = new HashMap(columnsByName);
                Iterator i = storeMgr.getColumnInfo(name, conn).iterator();

                while (i.hasNext())
                {
                    ColumnInfo ci = (ColumnInfo)i.next();
                    SQLIdentifier colName = new SQLIdentifier(dba, ci.columnName);

                    Column col = (Column)unvalidated.get(colName);

                    if (col == null)
                    {
                        if (!hasColumnName(colName))
                            throw new UnexpectedColumnException(this, colName);
                        /*
                         * Otherwise it's a duplicate column name in the
                         * metadata and we ignore it.  Cloudscape is known to
                         * do this, although I think that's probably a bug.
                         */
                    }
                    else
                    {
                        col.validate(ci);
                        unvalidated.remove(colName);
                    }
                }

                if (unvalidated.size() > 0)
                    throw new MissingColumnException(this, unvalidated.values());

                PrimaryKey expectedPK = getExpectedPrimaryKey();
                Map actualPKs = getExistingPrimaryKeys(conn);

                if (expectedPK == null)
                {
                    if (!actualPKs.isEmpty())
                        throw new WrongPrimaryKeyException(this, expectedPK, actualPKs.values());
                }
                else
                {
                    if (actualPKs.size() != 1 || !actualPKs.values().contains(expectedPK))
                        throw new WrongPrimaryKeyException(this, expectedPK, actualPKs.values());
                }
            }
        }

        state = TABLE_STATE_VALIDATED;

        return dbWasModified;
    }


    public boolean validateConstraints(int flags, Connection conn) throws SQLException
    {
        assertIsInitialized();

        boolean dbWasModified = false;

        if ((flags & VALIDATE) != 0)
        {
            boolean fksWereModified;
            boolean idxsWereModified;

            if (dba.createIndexesBeforeForeignKeys())
            {
                idxsWereModified = validateIndices(flags, conn);
                fksWereModified  = validateForeignKeys(flags, conn);
            }
            else
            {
                fksWereModified  = validateForeignKeys(flags, conn);
                idxsWereModified = validateIndices(flags, conn);
            }

            dbWasModified = fksWereModified || idxsWereModified;
        }

        return dbWasModified;
    }


    private boolean validateForeignKeys(int flags, Connection conn) throws SQLException
    {
        boolean dbWasModified = false;

        /*
         * Validate and/or create all foreign keys.
         */
        Map actualForeignKeysByName = getExistingForeignKeys(conn);
        int numActualFKs = actualForeignKeysByName.size();

        Map stmtsByFKName = getSQLAddFKStatements(actualForeignKeysByName);

        if (stmtsByFKName.isEmpty())
        {
            if (numActualFKs > 0)
                LOG.info("Validated " + numActualFKs + " foreign key(s) for table: " + this);
        }
        else
        {
//            if ((flags & AUTO_CREATE) == 0)
  //              throw new MissingForeignKeysException(this, stmtsByFKName.values());

            Statement stmt = conn.createStatement();

            try
            {
                Iterator i = stmtsByFKName.entrySet().iterator();

                while (i.hasNext())
                {
                    Map.Entry e = (Map.Entry)i.next();
                    String fkName = (String)e.getKey();
                    String stmtText = (String)e.getValue();

                    LOG.info("Creating foreign key constraint: " + getSchemaName() + '.' + fkName);

                    long startTime = System.currentTimeMillis();

                    stmt.execute(stmtText);

                    if (LOG.isDebugEnabled())
                        LOG.debug("Time = " + (System.currentTimeMillis() - startTime) + " ms: " + stmtText);

                    storeMgr.logSQLWarnings(stmt);
                }
            }
            finally
            {
                stmt.close();
            }

            dbWasModified = true;
        }

        return dbWasModified;
    }


    private boolean validateIndices(int flags, Connection conn) throws SQLException
    {
        boolean dbWasModified = false;

        /*
         * Validate and/or create all indices.
         */
        Map actualIndicesByName = getExistingIndices(conn);

        /*
         * Compute the number of existing indices *created by us*, which are
         * recognized by having a name that starts with the table name.  This
         * number is only used to control what gets logged below.  There are
         * often other system-generated indices.
         */
        int numActualIdxs = 0;
        Iterator names = actualIndicesByName.keySet().iterator();

        while (names.hasNext())
        {
            SQLIdentifier idxName = (SQLIdentifier)names.next();

            if (idxName.toString().startsWith(name.toString()))
                ++numActualIdxs;
        }

        Map stmtsByIdxName = getSQLCreateIndexStatements(actualIndicesByName);

        if (stmtsByIdxName.isEmpty())
        {
            if (numActualIdxs > 0)
                LOG.info("Validated " + numActualIdxs + " index(s) for table: " + this);
        }
        else
        {
            if ((flags & AUTO_CREATE) == 0)
                throw new MissingIndicesException(this, stmtsByIdxName.values());

            Statement stmt = conn.createStatement();

            try
            {
                Iterator i = stmtsByIdxName.entrySet().iterator();

                while (i.hasNext())
                {
                    Map.Entry e = (Map.Entry)i.next();
                    String idxName = (String)e.getKey();
                    String stmtText = (String)e.getValue();

                    LOG.info("Creating index: " + getSchemaName() + '.' + idxName);

                    long startTime = System.currentTimeMillis();

                    stmt.execute(stmtText);

                    if (LOG.isDebugEnabled())
                        LOG.debug("Time = " + (System.currentTimeMillis() - startTime) + " ms: " + stmtText);

                    storeMgr.logSQLWarnings(stmt);
                }
            }
            finally
            {
                stmt.close();
            }

            dbWasModified = true;
        }

        return dbWasModified;
    }


    public void drop(Connection conn) throws SQLException
    {
        LOG.info("Dropping table: " + this);

        super.drop(conn);
    }


    public void dropConstraints(Connection conn) throws SQLException
    {
        assertIsInitialized();

        if (!dba.supportsAlterTableDropConstraint())
            return;

        /*
         * There's no need to drop indices; we assume they'll go away quietly
         * when the table is dropped.
         */
        DatabaseMetaData dmd = conn.getMetaData();

        HashSet fkNames = new HashSet();
        Iterator i = storeMgr.getForeignKeyInfo(name, conn).iterator();

        while (i.hasNext())
        {
            ForeignKeyInfo fki = (ForeignKeyInfo)i.next();

            /*
             * Legally, JDBC drivers are allowed to return null names for
             * foreign keys.  If they do, we simply have to skip DROP
             * CONSTRAINT.
             */
            if (fki.fkName != null)
                fkNames.add(fki.fkName);
        }

        int numFKs = fkNames.size();

        if (numFKs > 0)
        {
            LOG.info("Dropping " + numFKs + " foreign key(s) for table: " + this);

            i = fkNames.iterator();
            Statement stmt = conn.createStatement();

            try
            {
                while (i.hasNext())
                {
                    String constraintName = (String)i.next();
                    String stmtText = "ALTER TABLE " + name + " DROP CONSTRAINT " + constraintName;

                    long startTime = System.currentTimeMillis();

                    stmt.execute(stmtText);

                    if (LOG.isDebugEnabled())
                        LOG.debug("Time = " + (System.currentTimeMillis() - startTime) + " ms: " + stmtText);

                    storeMgr.logSQLWarnings(stmt);
                }
            }
            finally
            {
                stmt.close();
            }
        }
    }


    protected List getExpectedForeignKeys()
    {
        assertIsInitialized();

        ArrayList foreignKeys = new ArrayList();
        Iterator i = columns.iterator();

        while (i.hasNext())
        {
            Column col = (Column)i.next();

            ClassMetaData cmd = ClassMetaData.forClass(col.getType());

            if (cmd != null)
            {
                ClassBaseTable referencedTable = (ClassBaseTable)storeMgr.getTable(cmd);

                if (referencedTable != null)
                    foreignKeys.add(new ForeignKey(col, referencedTable, true));
            }
        }

        return foreignKeys;
    }


    protected Set getExpectedIndices()
    {
        assertIsInitialized();

        HashSet indices = new HashSet();
        PrimaryKey pk = getExpectedPrimaryKey();
        Iterator i = getExpectedForeignKeys().iterator();

        /*
         * For each foreign key, add to the list an index made up of the "from"
         * column(s) of the key, *unless* those columns also happen to be the
         * leading columns of the primary key.  If they are, we're assuming that
         * they're going to be indexed anyway, regardless of whether that fact
         * gets reflected by getIndexInfo().
         */
        while (i.hasNext())
        {
            ForeignKey fk = (ForeignKey)i.next();

            if (!pk.startsWith(fk))
                indices.add(new Index(fk));
        }

        return indices;
    }


    private Map getExistingPrimaryKeys(Connection conn) throws SQLException
    {
        DatabaseMetaData dmd = conn.getMetaData();

        HashMap primaryKeysByName = new HashMap();
        ResultSet rs = dmd.getPrimaryKeys(null, getSchemaName(), name.getSQLIdentifier());

        try
        {
            while (rs.next())
            {
                SQLIdentifier pkName;
                String s = rs.getString(6);

                if (s == null)
                    pkName = new PrimaryKeyIdentifier(this);
                else
                    pkName = new SQLIdentifier(dba, s);

                PrimaryKey pk = (PrimaryKey)primaryKeysByName.get(pkName);

                if (pk == null)
                {
                    pk = new PrimaryKey(this);
                    primaryKeysByName.put(pkName, pk);
                }

                int keySeq = rs.getInt(5) - 1;
                SQLIdentifier colName = new SQLIdentifier(dba, rs.getString(4));

                Column col = (Column)columnsByName.get(colName);

                if (col == null)
                    throw new UnexpectedColumnException(this, colName);

                pk.setColumn(keySeq, col);
            }
        }
        finally
        {
            rs.close();
        }

        return primaryKeysByName;
    }


    private Map getExistingForeignKeys(Connection conn) throws SQLException
    {
        DatabaseMetaData dmd = conn.getMetaData();

        HashMap foreignKeysByName = new HashMap();
        Iterator i = storeMgr.getForeignKeyInfo(name, conn).iterator();

        while (i.hasNext())
        {
            ForeignKeyInfo fki = (ForeignKeyInfo)i.next();
            SQLIdentifier fkName;

            if (fki.fkName == null)
                fkName = new ForeignKeyIdentifier(this, foreignKeysByName.size());
            else
                fkName = new SQLIdentifier(dba, fki.fkName);

            boolean initiallyDeferred = fki.deferrability == DatabaseMetaData.importedKeyInitiallyDeferred;

            ForeignKey fk = (ForeignKey)foreignKeysByName.get(fkName);

            if (fk == null)
            {
                fk = new ForeignKey(initiallyDeferred);
                foreignKeysByName.put(fkName, fk);
            }

            BaseTable refTable = (BaseTable)storeMgr.getTable(new SQLIdentifier(dba, fki.pkTableName));

            if (refTable != null)
            {
                SQLIdentifier colName = new SQLIdentifier(dba, fki.fkColumnName);
                SQLIdentifier refColName = new SQLIdentifier(dba, fki.pkColumnName);

                Column col = (Column)columnsByName.get(colName);
                Column refCol = (Column)refTable.columnsByName.get(refColName);

                if (col == null)
                    throw new UnexpectedColumnException(this, colName);
                if (refCol == null)
                    throw new UnexpectedColumnException(this, refColName);

                fk.addColumn(col, refCol);
            }
        }

        return foreignKeysByName;
    }


    private Map getExistingIndices(Connection conn) throws SQLException
    {
        DatabaseMetaData dmd = conn.getMetaData();

        HashMap indicesByName = new HashMap();
        ResultSet rs = dmd.getIndexInfo(null, getSchemaName(), name.getSQLIdentifier(), false, true);

        try
        {
            while (rs.next())
            {
                short idxType = rs.getShort(7);

                if (idxType == DatabaseMetaData.tableIndexStatistic)
                    continue;

                SQLIdentifier idxName = new SQLIdentifier(dba, rs.getString(6));
                Index idx = (Index)indicesByName.get(idxName);

                if (idx == null)
                {
                    boolean isUnique = !rs.getBoolean(4);
                    idx = new Index(this, isUnique);
                    indicesByName.put(idxName, idx);
                }

                int colSeq = rs.getShort(8) - 1;
                SQLIdentifier colName = new SQLIdentifier(dba, rs.getString(9));

                Column col = (Column)columnsByName.get(colName);
                if (col == null)
                    throw new UnexpectedColumnException(this, colName);

                idx.setColumn(colSeq, col);
            }
        }
        finally
        {
            rs.close();
        }

        return indicesByName;
    }


    protected List getSQLCreateStatements()
    {
        assertIsInitialized();

        ArrayList stmts = new ArrayList();

        stmts.add(dba.getCreateTableStatement(this, (Column[])columns.toArray(new Column[columns.size()])));

        PrimaryKey pk = getExpectedPrimaryKey();

        if (pk != null)
            stmts.add(dba.getAddPrimaryKeyStatement(new PrimaryKeyIdentifier(this), pk));

        return stmts;
    }


    protected Map getSQLAddFKStatements(Map actualForeignKeysByName)
    {
        assertIsInitialized();

        HashMap stmtsByFKName = new HashMap();

        List expectedForeignKeys = getExpectedForeignKeys();

        Iterator i = expectedForeignKeys.iterator();
        int n = 1;

        while (i.hasNext())
        {
            ForeignKey fk = (ForeignKey)i.next();

            if (!actualForeignKeysByName.containsValue(fk))
            {
                ForeignKeyIdentifier fkName;

                do
                {
                    fkName = new ForeignKeyIdentifier(this, n++);
                } while (actualForeignKeysByName.containsKey(fkName));

                String stmtText = dba.getAddForeignKeyStatement(fkName, fk);

                stmtsByFKName.put(fkName.getSQLIdentifier(), stmtText);
            }
        }

        return stmtsByFKName;
    }


    private boolean isIndexReallyNeeded(Index requiredIdx, Collection actualIndices)
    {
        Iterator i = actualIndices.iterator();

        while (i.hasNext())
        {
            Index actualIdx = (Index)i.next();

            if (actualIdx.startsWith(requiredIdx))
                return false;
        }

        return true;
    }


    protected Map getSQLCreateIndexStatements(Map actualIndicesByName)
    {
        assertIsInitialized();

        HashMap stmtsByIdxName = new HashMap();

        Set expectedIndices = getExpectedIndices();

        Iterator i = expectedIndices.iterator();
        int n = 1;

        while (i.hasNext())
        {
            Index idx = (Index)i.next();

            if (isIndexReallyNeeded(idx, actualIndicesByName.values()))
            {
                IndexIdentifier idxName;

                do
                {
                    idxName = new IndexIdentifier(this, idx.getUnique(), n++);
                } while (actualIndicesByName.containsKey(idxName));

                String stmtText = dba.getCreateIndexStatement(idxName, idx);

                stmtsByIdxName.put(idxName.getSQLIdentifier(), stmtText);
            }
        }

        return stmtsByIdxName;
    }


    protected List getSQLDropStatements()
    {
        assertIsInitialized();

        ArrayList stmts = new ArrayList();
        stmts.add(dba.getDropTableStatement(this));

        return stmts;
    }
}
